feat(iOS, ContainedModal): Add basic setup for ContainedModal native component#4190
feat(iOS, ContainedModal): Add basic setup for ContainedModal native component#4190sgaczol wants to merge 28 commits into
Conversation
f74bf6a to
2d121b6
Compare
There was a problem hiding this comment.
Pull request overview
Adds a new experimental iOS-contained modal primitive (ContainedModal) and its bounding container (ContainedModalProvider) to react-native-screens, including the Fabric/codegen surface, native iOS implementation (host anchor + teleported content + presentation manager), and a single-feature test scenario in the example app.
Changes:
- Added JS components/types (with Android/Web no-op shims) and exported them via
react-native-screens/experimental. - Added Fabric codegen specs + component registry wiring for the new host/provider native components.
- Implemented the iOS native contained-modal stack (provider controller, host/proxy/state syncing, presentation manager/update coordination) and added a single-feature test scenario.
Reviewed changes
Copilot reviewed 45 out of 45 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts | Codegen spec for the provider native component (Fabric). |
| src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts | Codegen spec for the host native component (Fabric, interfaceOnly). |
| src/experimental/index.ts | Exports ContainedModal/Provider from the experimental entry point. |
| src/components/gamma/modals/contained-modal/index.ts | Barrel exports for the contained-modal components/types. |
| src/components/gamma/modals/contained-modal/ContainedModalProvider.web.tsx | Web no-op stub with warning. |
| src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts | Public JS types for ContainedModalProvider. |
| src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx | iOS/native implementation wrapper using the provider native component. |
| src/components/gamma/modals/contained-modal/ContainedModalProvider.android.tsx | Android no-op stub with warning. |
| src/components/gamma/modals/contained-modal/ContainedModal.web.tsx | Web no-op stub with warning. |
| src/components/gamma/modals/contained-modal/ContainedModal.types.ts | Public JS types for ContainedModal. |
| src/components/gamma/modals/contained-modal/ContainedModal.tsx | iOS/native implementation wrapper using a zero-sized host anchor. |
| src/components/gamma/modals/contained-modal/ContainedModal.android.tsx | Android no-op stub with warning. |
| package.json | Registers the new Fabric component classes in the codegen component map. |
| ios/stubs/RNSGammaStubs.mm | Adds Gamma stubs for the new component views. |
| ios/stubs/RNSGammaStubs.h | Adds Gamma stub declarations for the new component views. |
| ios/gamma/modals/contained-modal/RNSContainedModalUpdateFlags.h | Defines update flags for presentation/appearance/behavior. |
| ios/gamma/modals/contained-modal/RNSContainedModalUpdateCoordinator.mm | Implements update-flag coordination helpers. |
| ios/gamma/modals/contained-modal/RNSContainedModalUpdateCoordinator.h | Declares update-flag coordination helpers. |
| ios/gamma/modals/contained-modal/RNSContainedModalProviders.h | Declares provider protocol used to drive presentation. |
| ios/gamma/modals/contained-modal/RNSContainedModalProviderController.mm | Defines a provider VC that sets definesPresentationContext. |
| ios/gamma/modals/contained-modal/RNSContainedModalProviderController.h | Header for provider VC. |
| ios/gamma/modals/contained-modal/RNSContainedModalProviderComponentView.mm | Provider Fabric component view embedding the provider VC and reparenting children. |
| ios/gamma/modals/contained-modal/RNSContainedModalProviderComponentView.h | Header for provider component view + containerId API. |
| ios/gamma/modals/contained-modal/RNSContainedModalPresentationState.h | Presentation state enum for manager state machine. |
| ios/gamma/modals/contained-modal/RNSContainedModalPresentationManager.mm | Present/dismiss state machine + provider resolution/caching. |
| ios/gamma/modals/contained-modal/RNSContainedModalPresentationManager.h | Header for presentation manager. |
| ios/gamma/modals/contained-modal/RNSContainedModalHostShadowStateProxy.mm | Updates Fabric shadow state with teleported content bounds/origin offset. |
| ios/gamma/modals/contained-modal/RNSContainedModalHostShadowStateProxy.h | Header for host shadow-state proxy. |
| ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.mm | Host anchor Fabric component view: mounts children into content VC, manages touch handler + state sync. |
| ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.h | Header for host component view. |
| ios/gamma/modals/contained-modal/RNSContainedModalContentView.mm | Clear background content view used by the presented controller. |
| ios/gamma/modals/contained-modal/RNSContainedModalContentView.h | Header for content view reparenting helpers. |
| ios/gamma/modals/contained-modal/RNSContainedModalContentController.mm | Presented controller: update flushing + presentation manager invocation + delegate callbacks. |
| ios/gamma/modals/contained-modal/RNSContainedModalContentController.h | Header for content controller and its signals/delegate. |
| ios/gamma/modals/contained-modal/RNSContainedModalConfigurationApplicator.mm | Placeholder configuration applicator (scaffolding). |
| ios/gamma/modals/contained-modal/RNSContainedModalConfigurationApplicator.h | Header for configuration applicator (scaffolding). |
| common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostState.h | C++ state definition for host (frame size + content offset). |
| common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostShadowNode.h | Shadow node definition with overridden content-origin offset. |
| common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostShadowNode.cpp | Shadow node implementation. |
| common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostComponentDescriptor.h | Component descriptor adopting size from state (layout integration). |
| apps/src/tests/single-feature-tests/index.tsx | Adds ContainedModal scenario group to example app scenario registry. |
| apps/src/tests/single-feature-tests/contained-modal/test-contained-modal-base-ios/scenario.md | Manual test plan/scenario for contained modal behavior. |
| apps/src/tests/single-feature-tests/contained-modal/test-contained-modal-base-ios/scenario-description.ts | Scenario metadata shown in the test app. |
| apps/src/tests/single-feature-tests/contained-modal/test-contained-modal-base-ios/index.tsx | Single-feature test implementation showcasing provider-bounded presentation + touch routing. |
| apps/src/tests/single-feature-tests/contained-modal/index.ts | Scenario-group entry point for contained-modal tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| const ContainedModalScenarioGroup: ScenarioGroup<keyof typeof scenarios> = { | ||
| name: 'ContainedModal', | ||
| details: 'Single feature tests for ContainedModals', |
There was a problem hiding this comment.
'ContainedModals' makes more sense, especially when we add a different style (CurrentContext) in the future
LKuchno
left a comment
There was a problem hiding this comment.
Looks good, but I left few comments how we can optimize this to make manual testing a bit more convenient.
|
|
||
| 1. Launch the app and navigate to the **Basic functionality** screen. | ||
|
|
||
| - [ ] Expected: Content is shown with the "Provider: full screen (tap for partial)" button, an "Open contained modal" button, an "Outside count: 0" button, and (inside the provider region) an "Inside count: 0" button. |
There was a problem hiding this comment.
I would set a background color for the ContainedModalProvider (e.g., backgroundColor: Colors.NavyLightTransparent) to make the provider region visible. Right now, "(inside the provider region)" isn't visually testable.
There was a problem hiding this comment.
Information about color should be provided also in scenario imo.
| 6. Note the current "Inside count" value, then tap the "Inside count" button (the one | ||
| that sits in the region the modal was just covering) several times. Tap "Outside |
There was a problem hiding this comment.
I think there is not need to explain what "Inside count" button is.
| 6. Note the current "Inside count" value, then tap the "Inside count" button (the one | |
| that sits in the region the modal was just covering) several times. Tap "Outside | |
| 6. Note the current "Inside count" value, then tap the "Inside count" button several times. Tap "Outside |
|
|
||
| 10. Tap "Close". | ||
|
|
||
| - [ ] Expected: The modal dismisses smoothly and the screen returns to the partial-provider layout. "Inside count" is tappable again and "Outside count" keeps working. |
There was a problem hiding this comment.
I would add separate step here to check tapping on buttons or add this action to step 10 description.
There was a problem hiding this comment.
Some code cleanup is needed here. I wouldn't copy the whole FormSheet implementation unless the specific part is required right now.
such approach is temporary - implementing a custom shadow node will be handled in a separate pr
- ContainedModal.tsx - ContainedModal.types.ts - ContainedModalProvider.tsx - ContainedModalProvider.types.ts - index.tsx
…inerId for the modal
33e1e12 to
dc57862
Compare
LKuchno
left a comment
There was a problem hiding this comment.
Few more remarks, mostly suggestion to align with structure after PR#4201
|
|
||
| 1. Launch the app and navigate to the **Basic functionality** screen. | ||
|
|
||
| - [ ] Expected: Content is shown with the "Provider: full screen (tap for partial)" button, an "Open contained modal" button, an "Outside count: 0" button, and (inside the provider region) an "Inside count: 0" button. |
There was a problem hiding this comment.
Information about color should be provided also in scenario imo.
| import type { ScenarioGroup } from '@apps/tests/shared/helpers'; | ||
| import TestContainedModalBase from './test-contained-modal-base-ios'; | ||
| import TestContainedModalPresentationStyle from './test-contained-modal-presentation-style-ios'; | ||
|
|
There was a problem hiding this comment.
After PR #4201 we should also add this exports:
| export { default as TestContainedModalBase } from './test-contained-modal-base-ios'; | |
| export { default as TestContainedModalPresentationStyle } from './test-contained-modal-presentation-style-ios'; | |
|
|
||
| const CONTAINER_ID = 'contained-modal-transparent'; | ||
|
|
||
| export function App() { |
There was a problem hiding this comment.
After PR#4201 we follow this function name convention:
| export function App() { | |
| function TestContainedModalPresentationStyle() { |
| }, | ||
| }); | ||
|
|
||
| export default createScenario(App, scenarioDescription); |
There was a problem hiding this comment.
After PR mentioned above:
| export default createScenario(App, scenarioDescription); | |
| export default createScenario(TestContainedModalPresentationStyle, scenarioDescription); |
|
|
||
| const CONTAINER_ID = 'contained-modal-base'; | ||
|
|
||
| export function App() { |
There was a problem hiding this comment.
After PR#4201 we should follow this function name convention:
| export function App() { | |
| function TestContainedModalBase() { |
| }, | ||
| }); | ||
|
|
||
| export default createScenario(App, scenarioDescription); |
There was a problem hiding this comment.
Following above changes:
| export default createScenario(App, scenarioDescription); | |
| export default createScenario(TestContainedModalBase, scenarioDescription); |
Things to do
Description
Introducing the iOS implementation of the standalone
ContainedModalcomponent, together with itsContainedModalProvider.A
ContainedModalis presented within the bounds of a provider instead of over the whole window. The two are matched by id: the modal'stargetContainerIdis compared against a provider'scontainerId, and the modal is presented inside the matching provider's frame. This makes it possible to scope a modal to an arbitrary region of the screen (e.g. one column of a split layout) rather than the full screen.Key architectural decisions:
ContainedModalhost is a zero-sized, absolutely-positioned logical anchor — it never participates in layout of its siblings. The actual React children are "teleported" and mounted inside a separateUIViewControllerhierarchy (RNSContainedModalContentController/RNSContainedModalContentView), so the host'shitTest:always returnsniland never intercepts touches meant for the content behind it.ContainedModalProvideris, by contrast, a regular space-filling container. Its bounds define the presentation area, and it hosts theUIViewControllerthe modal is presented from (resolved once, lazily, by walking up to the provider whosecontainerIdmatches).isOpenfromfalse → truepresents the modal, andtrue → falsedismisses it. The present/dismiss lifecycle and its state machine live inRNSContainedModalPresentationManager, kept separate from the controller and the component view.transparentprop:true(the default) usesUIModalPresentationOverCurrentContext, so the provider's content stays visible behind the presented modal, whilefalseusesUIModalPresentationCurrentContext, replacing it. The transition style will eventually be exposed as a prop too, but for now it is hardcoded toUIModalTransitionStyleCrossDissolve.This mirrors the architecture established for the standalone
FormSheetcomponent (#3947), reusing the same host-anchor + teleported-content + presentation-manager split.Note
ContainedModalis currently an iOS-only component. The Android and Web entry points are present but no-ops for now. It is exported fromreact-native-screens/experimental.Closes https://github.com/software-mansion/react-native-screens-labs/issues/1359
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1361
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1364
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1367
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1356
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1362
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1369
Changes
ContainedModal(isOpen,targetContainerId) andContainedModalProvider(containerId,style) components, types, and platform splits (.tsx,.android.tsx,.web.tsx).src/experimental.ContainedModalHostNativeComponentandContainedModalProviderNativeComponent.RNSContainedModalHostShadowNode,RNSContainedModalHostState,RNSContainedModalHostComponentDescriptor).ios/gamma/modals/contained-modal/)RNSGammaStubs).RCTSurfaceTouchHandleris attached lazily on present/layout and detached onviewDidDisappear:(via the controller→host delegate), in addition toinvalidate. This prevents a dismissed modal from leaving behind a dead touch zone that silently swallows taps on the content behind it, and it re-attaches correctly on re-present.package.jsonwiring for the new sources.Caution
Dynamic content-origin updates aren't supported in the context of synchronization with the ShadowTree state. If an ancestor's offset changes, the frame of our host view might not be updated at all — from the host's perspective the frame doesn't change (
syncShadowNodeStateonly schedules a new state update when the computed origin or content bounds actually change), so no content re-layout is triggered.Note
The JavaScript files unrelated to the single-feature test (a separate batch of
.js/.tschanges) are tracked in their own PR — see #4171.After - visual documentation
ContainedModalDemo.mov
Test plan
Run single feature tests regarding
ContainedModaland verify if the modal's behaviors matches the expected ones inscenario.mdfiles.Checklist